// By: Barry Snyder heavily leveraging the MYZ script by The Aaron
// look into adding 3d dice https://wiki.roll20.net/API:Chat
var TalesFromtheLoop = TalesFromtheLoop || (function() {
'use strict';
var version = '1',
lastUpdate = 'April 11th, 2018',
schemaVersion = 0.1,
symbols = {
atom: ''+'x269B;',
push: ''+'10150;'
},
skillDie = 'https://i.imgur.com/W1idZHz.png',
attrDie = 'https://i.imgur.com/YmhTKsUt.png',
itemDie = 'https://i.imgur.com/4S3h10T.png',
colors = {
looporange: '#ED7012', //successes
white: '#FFFFFF', //base-attribute rolls
black: '#1d1d1d', //item roll
loopyellow: '#FBBB1D', //skill rolls
loopblue: '#128fed' //
},
defaults = {
css: {
button: {
'border': '1px solid #128fed',
'border-radius': '.25em',
'background-color': '#006dcc',
'font-weight': 'bold',
'padding': '.225em',
'text-align': 'center',
'color': 'white'
},
errorMsg: {
'font-size': '.8em',
'border': '.2em solid #990000',
'border-radius': '1em',
'background-color': '#cccccc',
'margin': '0 .1em',
'padding': '.1em .6em',
'overflow': 'hidden'
},
errorMsgLabel: {
'font-weight': 'bold',
'background-color': '#990000',
'color': '#ffffff',
'display': 'inline-block',
'padding': '.1em .6em',
'border-radius': '0 0 .75em 0',
'margin': '-.1em .2em 0em -.6em'
},
optionalMsg: { // background and text for optional text message
'font-size': '.6em',
'border': '.1em solid #1222ed', // border around entire message
'border-radius': '.5em',
'background-color': '#a0d2f8', // background of message text
'margin': '0 .1em .2em .1em',
'padding': '.1em .4em',
'overflow': 'hidden'
},
optionalMsgLabel: { // background and text for optional text label
'font-weight': 'bold',
'background-color': '#006dcc',
'color': '#ffffff', // label text
'display': 'inline-block',
'padding': '.1em .6em .1em .4em',
'border-radius': '0 0 .75em 0',
'margin': '-.1em .2em 0em -.4em'
},
loopMsg: { // roll background and border
'font-size': '1.25em',
'border': '.25em solid #128fed',
'border-radius': '.5em',
'background-color': '#5FACFC',
'background-repeat': 'no-repeat',
'background-position': 'top',
'background-size': '100%',
'margin': '0 0',
'padding': '.3em',
'overflow': 'hidden'
},
loopMsgLabelContainer: {
'max-width': '10em',
'float': 'left',
'font-size': '1.5em',
'margin-right': '.2em'
},
loopMsgLabel: { // Summary of Successes
'border': '.1em solid #cccccc',
'border-radius': '.25em',
'font-weight': 'bold',
'padding': '.225em',
'margin-bottom': '.225em',
'text-align': 'center'
},
loopDie: {
'display': 'inline-block',
'border': '1px solid black',
'border-radius': '.3em',
'padding': '.2em 0 0 0',
'text-align': 'center',
'width': '1.4em',
'height': '1.1em',
'float': 'left',
'margin-right': '.1em',
'margin-bottom': '.1em'
}
}
},
reportingModes = {
'off': 'None',
'gm': 'GM',
'gm+player': 'GM & Player',
'public': 'Public'
},
templates = {},
buildTemplates = function() {
templates.cssProperty =_.template(
'<%=name %>: <%=value %>;'
);
templates.style = _.template(
'style="<%='+
'_.map(css,function(v,k) {'+
'return templates.cssProperty({'+
'defaults: defaults,'+
'templates: templates,'+
'name:k,'+
'value:v'+
'});'+
'}).join("")'+
' %>"'
);
templates.button = _.template(
' href="<%= command %>"><%= label||"Button" %>'
);
templates.errorMsg = _.template(
'
>'+
'
>'+
'<%= label %>'+
'
'+
'<%= text %>'+
'
'
);
templates.optionalMsg = _.template(
'>'+
'<% if(label){ %>'+
'
>'+
'<%= label %>'+
'
'+
'<% } %>'+
'<%= text %>'+
'
'
);
templates.die = _.template(
' >'+
'<%= text %>'+
'
'
);
templates.outputLabel = _.template(
' >'+
'<%= labelText %>'+
'
'
);
templates.output = _.template(
' >'+
'
>'+
'<%= results %>'+
'
'+
'<%= diceImages %>'+
'
'
);
},
makeErrorMsg = function(msg){
return templates.errorMsg({
text: msg,
label: 'Error',
templates: templates,
defaults: defaults,
css: {}
});
},
makeOptionalMsg = function(label,msg){
return templates.optionalMsg({
text: msg,
label: label,
templates: templates,
defaults: defaults,
css: {}
});
},
makeOptionalText = function (optional){
return ''+_.map(optional,function(o){
return makeOptionalMsg(o.label,o.msg);
}).join('')+'
';
},
makeLabel = function(text,bgcolor,color){
bgcolor=bgcolor;
color=color;
return templates.outputLabel({
labelText: text,
templates: templates,
defaults: defaults,
css: {
'background-color': bgcolor,
'color': color
}
});
},
makeOutput = function (results,diceImages){
return templates.output({
results: results,
diceImages: diceImages,
templates: templates,
defaults: defaults,
css: {}
});
},
makeButton = function(command, label, backgroundColor, color){
return templates.button({
command: command,
label: label,
templates: templates,
defaults: defaults,
css: {
color: color,
'background-color': backgroundColor
}
});
},
checkInstall = function() {
log('-=> TalesFromtheLoop v'+version+' <=- ['+(lastUpdate)+']');
if( ! _.has(state,'TalesFromtheLoop') || state.TalesFromtheLoop.version !== schemaVersion) {
log(' > Updating Schema to v'+schemaVersion+' <');
switch(state.TalesFromtheLoop && state.TalesFromtheLoop.version) {
case 0.1:
state.TalesFromtheLoop.config = {
reportMode: 'public'
};
/* break; // intentional drop through */
/* falls through */
case 0.2:
state.TalesFromtheLoop.playerRolls={};
state.TalesFromtheLoop.sequence=0;
/* break; // intentional drop through */
/* falls through */
case 0.3:
state.TalesFromtheLoop.config.gmCanPush=false;
/* break; // intentional drop through */
/* falls through */
case 'UpdateSchemaVersion':
state.TalesFromtheLoop.version = schemaVersion;
break;
default:
state.TalesFromtheLoop = {
version: schemaVersion,
config: {
reportMode: 'public',
gmCanPush: false
},
sequence: 0,
playerRolls: {}
};
break;
}
}
buildTemplates();
},
ch = function (c) {
var entities = {
'<' : 'lt',
'>' : 'gt',
"'" : '#39',
'@' : '#64',
'{' : '#123',
'|' : '#124',
'}' : '#125',
'[' : '#91',
']' : '#93',
'"' : 'quot',
'-' : 'mdash',
' ' : 'nbsp'
};
if(_.has(entities,c) ){
return ('&'+entities[c]+';');
}
return '';
},
makeConfigOption = function(config,command,text) {
var onOff = (config ? 'On' : 'Off' ),
color = (config ? '#5bb75b' : '#faa732' );
return ''+
'
'+
makeButton(command,onOff,color)+
'
'+
text+
'
'+
'
';
},
makeConfigOptionSelection = function(config,values,command,text) {
var buttons=_.map(values,function(v,k){
return makeButton(
command+' '+k,
v,
(config === k ? '#5bb75b' : '#faa732' )
);
});
return ''+
text+
'
'+
buttons.join('') +
'
'+
'
'+
'
';
},
getConfigOption_GMCanPush = function() {
return makeConfigOption(
state.TalesFromtheLoop.config.gmCanPush,
'!loop-config --toggle-gm-can-push',
'GM Can Push determines if the GM is allowed to push a player'+ch("'")+' roll.'
);
},
getConfigOption_ErrorReporting = function() {
return makeConfigOptionSelection(
state.TalesFromtheLoop.config.reportMode,
reportingModes,
'!loop-config --set-report-mode',
'Report Mode determines when and how invalid push attempts are reported.'
);
},
getAllConfigOptions = function() {
return getConfigOption_GMCanPush() + getConfigOption_ErrorReporting();
},
showHelp = function(playerid) {
let who=(getObj('player',playerid)||{get:()=>'API'}).get('_displayname');
sendChat('','/w "'+who+'" '+
''+
'
'+
'Tales from the Loop v'+version+
'
'+
'
'+
'
Tales from the Loop automates the rolling of Attribute, Skill, and Item dice.
'+
'
'+
'
Commands'+
'
'+
'
!loop '+ch('[')+'Attribute Roll'+ch(']')+' '+ch('[')+'Skill Roll'+ch(']')+' '+ch('[')+'Item Roll'+ch(']')+' '+ch('[')+'--'+ch('<')+'Label'+ch('>')+ch('|')+ch('<')+'Message'+ch('>')+' ...'+ch(']')+''+
'
'+
'
Performs a Tales from the Loop Roll.
'+
'
'+
'- '+
'
Attribute Roll '+ch('-')+' An inline dice expression rolling the d6s. 6s are counted as a success ('+symbols.atom+'). Example: '+ch('[')+ch('[')+'2d6'+ch(']')+ch(']')+
' '+
'- '+
'
Skill Roll '+ch('-')+' An inline dice expression rolling the d6s. 6s are counted as a success ('+symbols.atom+'). Example: '+ch('[')+ch('[')+'1d6'+ch(']')+ch(']')+
' '+
'- '+
'
Item Roll '+ch('-')+' An inline dice expression rolling the d6s ('+symbols.atom+'). Example: '+ch('[')+ch('[')+'2d6'+ch(']')+ch(']')+
' '+
'- '+
''+ch('[')+'--'+ch('<')+'Label'+ch('>')+ch('|')+ch('<')+'Message'+ch('>')+' ...'+ch(']')+' '+ch('-')+' An optional set of text to be shown above the die roll. Label may be omitted to just provid a text field. You can specify as many optional descriptions as you like.'+
'
'+
'
'+
'!loop '+ch('[')+ch('[')+'2d6'+ch(']')+ch(']')+' '+ch('[')+ch('[')+'1d6'+ch(']')+ch(']')+' '+ch('[')+ch('[')+'2d6'+ch(']')+ch(']')+' '+' --Name|Billy --Skill|Move (Body) --Item|Nike High Tops'+
''+
'
'+
' '+
'
'+
'
'+
'
'+
'
'+
'
!wloop '+ch('[')+'Attribute Roll'+ch(']')+' '+ch('[')+'Skill Roll'+ch(']')+' '+ch('[')+'Item Roll'+ch(']')+' '+ch('[')+'Legend Roll'+ch(']')+''+
'
'+
'
Identical to !loop except that the results are whispered to the player rolling and the GM.
'+
'
'+
'
'+
( playerIsGM(playerid) ?
'
Configuration' + getAllConfigOptions() :
''
)+
'
'
);
},
getDiceCounts = function(msg,idx) {
var rolls = {};
if( msg.inlinerolls &&
msg.inlinerolls[idx] &&
msg.inlinerolls[idx].results &&
msg.inlinerolls[idx].results.rolls[0]
) {
_.each(msg.inlinerolls[idx].results.rolls,function(res){
rolls=_.reduce(_.map(res.results,function(r){
return r.v;
}).sort() || [], function(m,r){
m[r]=(m[r]||0)+1;
return m;
},rolls);
});
}
return rolls;
},
getDiceArray = function(c) {
return _.reduce(c,function(m,v,k){
_.times(v,function(){m.push(k);});
return m;
},[]);
},
makeDiceImages = function(dice,bgcolor,color){
bgcolor=bgcolor||'black';
color=color||'white';
return _.map(dice,function(r){
return templates.die({
text: r,
templates:templates,
defaults:defaults,
css: {
'background-color': bgcolor,
'color': color
}
});
}).reverse().join('');
},
getRollableDiceCount = function(dice){
return _.filter(dice,function(v){return (v+'').match(/^\d+$/);}).length;
},
makeRerollExpression = function(dice, dieType='d6'){
var cnt = getRollableDiceCount(dice);
return ' '+ch('[')+ch('[')+cnt+dieType+ch(']')+ch(']')+' ';
},
validatePlayerRollHash = function(playerid,hash,base,skill,item){
var obj=state.TalesFromtheLoop.playerRolls[playerid];
return (obj && obj.hash === hash &&
obj.dice.baseDice === base &&
obj.dice.skillDice === skill &&
obj.dice.itemDice === item
);
},
getCountsForRoll = function(playerid,hash){
if(hash && _.has(state.TalesFromtheLoop.playerRolls,playerid)){
return state.TalesFromtheLoop.playerRolls[playerid].counts;
}
return {
success: 0,
pushes: 0
};
},
getOptionalForRoll = function(playerid,hash){
if(hash && _.has(state.TalesFromtheLoop.playerRolls,playerid)){
return state.TalesFromtheLoop.playerRolls[playerid].optional;
}
return [];
},
recordPlayerRollHash = function(playerid, obj){
state.TalesFromtheLoop.playerRolls[playerid]=obj;
},
reportBadPushCounts = function(playerid){
var player=getObj('player',playerid);
switch(state.TalesFromtheLoop.config.reportMode){
case 'public':
sendChat(player.get('displayname'),'/direct '+
makeErrorMsg('Attempting to push with modified dice counts.')
);
break;
case 'gm+player':
if(!playerIsGM(playerid)){
sendChat('','/w "'+player.get('displayname')+'" '+
makeErrorMsg('Do not adjust the number of dice when pushing.')
);
}
/* falls through */
/* break; // intentional drop thru */
case 'gm':
if(playerIsGM(playerid)){
sendChat('','/w gm '+
makeErrorMsg('Cannot adjust the number of dice in a Push attempt.')
);
} else {
sendChat('','/w gm '+
makeErrorMsg(player.get('displayname')+' attempted to Push with an altered number of dice.')
);
}
break;
default:
break;
}
},
reportBadPush = function(playerid){
var player=getObj('player',playerid);
switch(state.TalesFromtheLoop.config.reportMode){
case 'public':
sendChat(player.get('displayname'),'/direct '+
makeErrorMsg('Cannot Push old rolls. Please use the current result'+ch("'")+'s Push button.')
);
break;
case 'gm+player':
if(!playerIsGM(playerid)){
sendChat('','/w "'+player.get('displayname')+'" '+
makeErrorMsg('Cannot Push old rolls. Please use the current result'+ch("'")+'s Push button.')
);
}
/* break; // intentional drop thru */
/* falls through */
case 'gm':
if(playerIsGM(playerid)){
sendChat('','/w gm '+makeErrorMsg('Cannot Push old rolls.'));
} else {
sendChat('','/w gm '+makeErrorMsg(player.get('displayname')+' attempted to Push from an old roll.'));
}
break;
default:
break;
}
},
reportNotAllowed = function(playerid,ownerid){
var player=getObj('player',playerid),
owner=getObj('player',ownerid);
switch(state.TalesFromtheLoop.config.reportMode){
case 'public':
sendChat(player.get('displayname'),'/direct '+
makeErrorMsg('Cannot Push '+owner.get('displayname')+ch("'")+'s roll.')
);
break;
case 'gm+player':
if(!playerIsGM(playerid)){
sendChat('','/w "'+player.get('displayname')+'" '+
makeErrorMsg('Cannot Push '+owner.get('displayname')+ch("'")+'s roll.')
);
}
/* break; // intentional drop thru */
/* falls through */
case 'gm':
if(playerIsGM(playerid)){
sendChat('','/w gm '+
makeErrorMsg('Cannot Push '+owner.get('displayname')+ch("'")+'s roll because GM Can Push is not enabled. See '+makeButton('!loop-config','Configuration')+' for details.')
);
} else {
sendChat('','/w gm '+makeErrorMsg(player.get('displayname')+' attempted to Push '+owner.get('displayname')+ch("'")+'s roll.'));
}
break;
default:
break;
}
},
handleInput = function(msg_orig) {
var msg = _.clone(msg_orig),
args,
optional,
who,
rollIndices=[],
baseDice, // base(attribute): white
skillDice, // skill: loopyellow
itemDice, // item: black
baseDiceArray,
skillDiceArray,
itemDiceArray,
successes=0,
pushes=0,
pushedValues,
push=false,
pushButton,
w=false,
cmd,
hash,
matches,
owner,
output;
if (msg.type !== "api") {
return;
}
if(_.has(msg,'inlinerolls')){
rollIndices=_.map(msg.content.match(/\$\[\[(\d+)\]\]/g),function(i){
return i.match(/\d+/)[0];
});
msg.content = _.chain(msg.inlinerolls)
.reduce(function(m,v,k){
m['$[['+k+']]']=v.results.total || 0;
return m;
},{})
.reduce(function(m,v,k){
return m.replace(k,v);
},msg.content)
.value();
}
who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname');
optional = msg.content.split(/\s+--/);
args = optional.shift().split(/\s+/);
optional = _.map(optional,function(o){
var s=o.split(/\|/),
k=s.shift();
s=s.join('|');
return {
label: k,
msg: s
};
});
cmd=args.shift();
matches=cmd.match(/^(!\S+)\[([^\]]+)\]/);
if(matches){
cmd=matches[1];
hash=parseInt(matches[2],10);
}
switch(cmd) {
case '!wforl':
w=true;
/* break; */ // Intentional drop through
/* falls through */
case '!loop':
if( 0 === args.length || _.contains(args,'--help')) {
showHelp(msg.playerid);
return;
}
push=!!hash;
if( push &&
( _.has(state.TalesFromtheLoop.playerRolls,msg.playerid) &&
( hash !== state.TalesFromtheLoop.playerRolls[msg.playerid].hash) )
) {
owner = _.find(state.TalesFromtheLoop.playerRolls,function(v){
return hash === v.hash;
});
if(owner){
owner=owner.playerid;
if(!(playerIsGM(msg.playerid) && state.TalesFromtheLoop.config.gmCanPush)){
reportNotAllowed(msg.playerid,owner);
return;
}
} else {
reportBadPush(msg.playerid);
return;
}
}
owner = owner || msg.playerid;
baseDice=getDiceCounts(msg,rollIndices[0]);
skillDice=getDiceCounts(msg,rollIndices[1]);
itemDice=getDiceCounts(msg,rollIndices[2]);
if(push &&
(
_.has(state.TalesFromtheLoop.playerRolls,owner) &&
( hash === state.TalesFromtheLoop.playerRolls[owner].hash)
) &&
!validatePlayerRollHash(owner,hash,
getDiceArray(baseDice).length,
getDiceArray(skillDice).length,
getDiceArray(itemDice).length
)
){
reportBadPushCounts(msg.playerid);
return;
}
pushedValues=getCountsForRoll(owner,hash);
successes = pushedValues.success + (baseDice['6']||0) + (skillDice['6']||0) + (itemDice['6']||0);
if(push){pushes = pushedValues.pushes+1}; // only update if there is a push ... should be at 0 when a roll is first made
optional = (optional.length && optional) || getOptionalForRoll(owner,hash);
baseDiceArray=_.map(getDiceArray(baseDice),function(v){
switch(v){
case '6':
return symbols.atom;
default:
return v;
}
});
skillDiceArray=_.map(getDiceArray(skillDice),function(v){
switch(v){
case '6':
case '-6':
return symbols.atom;
default:
return v;
}
});
itemDiceArray=_.map(getDiceArray(itemDice),function(v){
switch(v){
case '6':
return symbols.atom;
default:
return v;
}
});
// record push
hash=(++state.TalesFromtheLoop.sequence);
recordPlayerRollHash(owner,{
hash: hash,
playerid: owner,
dice: {
baseDice: getRollableDiceCount(baseDiceArray),
skillDice: getRollableDiceCount(skillDiceArray),
itemDice: getRollableDiceCount(itemDiceArray)
},
counts: {
success: successes,
pushes: pushes
},
optional: optional
});
pushButton = (_.reduce([baseDiceArray,skillDiceArray,itemDiceArray],function(m,dice){ return m+getRollableDiceCount(dice);},0) ?
makeButton(
'!'+(w?'w':'')+'loop['+hash+'] '+
makeRerollExpression(baseDiceArray)+
makeRerollExpression(skillDiceArray)+
makeRerollExpression(itemDiceArray),
symbols.push+' '+pushes
) :
''
);
output = makeOutput([
makeLabel( successes+' '+symbols.atom, colors.looporange, colors.white),
pushButton
].join(''),
[
makeOptionalText(optional),
makeDiceImages(baseDiceArray,colors.white, colors.black),
makeDiceImages(skillDiceArray,colors.loopyellow, colors.black),
makeDiceImages(itemDiceArray,colors.black)
].join('')
);
who=getObj('player',owner).get('displayname');
if(w){
sendChat(msg.who, '/w gm '+output);
if(!playerIsGM(owner)){
sendChat(who, '/w "'+who+'" '+output);
}
} else {
sendChat(who, '/direct '+output);
}
break;
case '!loop-config':
if(!playerIsGM(msg.playerid)){
return;
}
args = _.rest(msg.content.split(/\s+--/));
if(_.contains(args,'help')) {
showHelp(msg.playerid);
return;
}
if(!args.length) {
sendChat('','/w "'+who+'" '+
''+
'
'+
'TalesFromtheLoop v'+version+
'
'+
'
Configuration'+
getAllConfigOptions()+
'
'
);
return;
}
_.each(args,function(a){
var opt=a.split(/\s+/);
switch(opt.shift()) {
case 'toggle-gm-can-push':
state.TalesFromtheLoop.config.gmCanPush=!state.TalesFromtheLoop.config.gmCanPush;
sendChat('','/w "'+who+'" '+
''+
getConfigOption_GMCanPush()+
'
'
);
break;
case 'set-report-mode':
if(_.has(reportingModes,opt[0])){
state.TalesFromtheLoop.config.reportMode=opt[0];
sendChat('','/w "'+who+'" '+
''+
getConfigOption_ErrorReporting()+
'
'
);
} else {
sendChat('','/w "'+who+'" '+
'Unsupported Reporting Mode Option:
'+opt[0]+''
);
}
break;
default:
sendChat('','/w "'+who+'" '+
'Unsupported Option:
'+a+''
);
}
});
break;
}
},
registerEventHandlers = function() {
on('chat:message', handleInput);
};
return {
CheckInstall: checkInstall,
RegisterEventHandlers: registerEventHandlers
};
}());
on('ready',function() {
'use strict';
TalesFromtheLoop.CheckInstall();
TalesFromtheLoop.RegisterEventHandlers();
});